Skip to content

fix(desktop): keep session workspace action identities fixed - #4110

Open
Astro-Han wants to merge 6 commits into
apache:mainfrom
Astro-Han:fix/renderer-stabilize-session-workspace-actions
Open

fix(desktop): keep session workspace action identities fixed#4110
Astro-Han wants to merge 6 commits into
apache:mainfrom
Astro-Han:fix/renderer-stabilize-session-workspace-actions

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Switching a session held the renderer at 34% CPU with peaks near 77%, and roughly 58% of that was React render and commit work. The cause is one function identity.

setActiveId was a function declaration in the useAppShellSessionWorkspace body, so it changed identity on every AppShell render. As activateSession it invalidated openSession's useCallback, then the Session navigation controller's commands, then the host's rowActions and onSelectSession, then renderSessionRow — which defeated SessionNavRow's memo on every commit. A single switch re-rendered all 32 sidebar rows about twenty times, and because every Astryx button removes and re-adds its inline anchor-name per render, that switch also produced roughly 2,500 style writes and the style recalculation they force.

Every dependency these actions close over is a ref box, a React state setter, or a method of the once-created session-UI controller, so they are constant by construction rather than by discipline. createSessionWorkspaceActions moves them out of the render body and the hook instantiates it once; refreshSessions and seedSessions get the same treatment. They are deliberately not routed through useStableActions, whose facade exists for factories whose closures do capture changing deps — paying for that indirection here would buy nothing.

Smaller pieces ride along, each its own commit:

  • @maka/ui's formatAbsoluteTimestamp was a second copy of the Intl options @maka/core/relative-time already owned, and built a formatter per call — about 1,300 per switch, since the sidebar reads one per row for the tooltip and one for the accessible name. Core's cache could not have absorbed them either: getRelativeFormat and getAbsoluteFormat shared one cachedLocale and cleared each other on a miss, so alternating readings rebuilt a formatter every call. Core now caches each with its own locale and exports the function, and the three UI call sites import it from there. Profiling attributed ~33 ms per switch to this, and an A/B swapping in a memoising Intl.DateTimeFormat moved renderer JS by less than the run-to-run spread — a duplicate-authority removal, not a measurable win.
  • Two declarations the move left without an owner: the local MessageListUpdater copies in app-shell-{chat,turn,revision}-actions, and the alias re-export of formatAbsoluteTimestamp in chat-display-helpers.
  • A contract spec that budgets the rail's DOM writes for one switch, described under Review focus.

Refs #4109

Behaviour differences worth naming

Neither is reachable from a current call site; both are recorded because they widen what a future one could do.

  • The moved formatAbsoluteTimestamp drops a typeof Intl === 'undefined' fallback to toISOString(). Core's module has never had that guard and formatRelativeTimestamp's absolute branch already called Intl unconditionally, so it only protected a runtime that would fail a line later.
  • Core's signature defaults locale to 'zh', where the UI copy required it. All four call sites pass it explicitly; the default matches the module's three sibling formatters.

Verification

All measurements are same-instance A/B: the two identities were made runtime-switchable and alternated inside one running dev app, six switches each. Cross-instance comparison is not usable here — restarting the app moves these numbers by more than the effect.

per session switch unstable stable
Row renders 459 85
memo hits 0 432
DOM mutations 3,438 1,992
Renderer JS 521 ms 380 ms
Renderer CPU under a repeated-switch loop 34% (peak 77%) 24% (peak 55%)

Renderer JS fell in 6 of 6 paired runs. Idle CPU was 0.1% before and after; this workload is entirely interaction-driven.

Ran locally:

  • session-workspace-action-identity, session-navigation-controller, relative-time — pass
  • session-rail-render-contract (Playwright) — pass, three consecutive runs
  • tsc -p apps/desktop/tsconfig.renderer.json --noEmit, @maka/core and @maka/ui builds, npm run format — clean

Not run: the full repository suite, and the end-to-end check on a dev app built from this branch — a dev app from another checkout holds the shared profile lock. The Playwright spec covers the same observable on a clean fixture, so the gap is the manual pass, not the assertion.

Falsifiability was checked for both new tests by reverting the fix in the built renderer bundle. The identity test fails with setActiveId changed identity between renders; the contract spec fails on rows-touched, 12 of 12 rows written where 2 is the budget. Restoring the mutual cache invalidation in core fails the formatter test.

Review focus

The contract spec is the piece worth arguing about. It asserts an outcome rather than an identity, because this rail has had several independent regressions of the same shape and each was invisible to the others. An identity assertion pins one mechanism in one hook; the next plain function declaration upstream passes every existing check, since the dependency arrays stay correct and useExhaustiveDependencies has nothing to flag.

What carries the contract is which rows were written, not how many writes there were. A switch touches the leaving row and the arriving row and nothing else, at any rail length. A total budget cannot say that: 3 * rows is 1.5 whole-rail renders, so a regression that re-renders the rail exactly once — the likeliest one, since renderSessionRow depends on rowActions, sessionMeta and three Sets — would have passed. The spec also counts row remounts, because React sets attributes before insertion and an attribute-only observer reads a whole rail remounting as cheaper than a re-render, and asserts the counter fired at all, because its sensitivity comes from an Astryx ref callback that upstream could reasonably memoise.

It also covers ground this PR does not fix. A switch still produces about twenty commits, and why is not yet attributed; it is not animation-driven, since emulating prefers-reduced-motion cuts requestAnimationFrame callbacks from 188 to 13 while the render count is unchanged. That cascade multiplies whatever the rail costs per render, and anything that raises it shows up in this budget. Tracked in #4109 along with the structural direction — letting the rail subscribe through the store seam app-shell-session-ui-state.ts already establishes, instead of receiving props through five layers.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code. It drove the CDP profiling and DOM probes that isolated the cause and produced every number above, then wrote the change, the tests, and this description. Three Claude Code agents then reviewed the branch adversarially — runtime correctness, formatter equivalence, and test validity — each tasked with refuting the PR's claims rather than confirming them; they found no correctness defect, and every finding they did raise about the tests is addressed in the last three commits. That review is AI output and does not substitute for human review. The human contributor reviewed the diff, the commit messages, and the measurement method. Generated-by trailers are on all six commits.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

@github-actions github-actions Bot added the effort/L Under 1000 readable lines label Aug 28, 2026
@Astro-Han
Astro-Han marked this pull request as ready for review August 29, 2026 03:02
`setActiveId` and its siblings were function declarations in the
`useAppShellSessionWorkspace` body, so every AppShell render handed
consumers new identities. `activateSession` alone invalidated
`openSession`'s `useCallback`, then the Session navigation controller's
`commands`, then the host's `rowActions` and `onSelectSession`, then
`renderSessionRow` — which defeated `SessionNavRow`'s `memo` on every
commit. One session switch re-rendered all 32 sidebar rows about twenty
times, and each Astryx button rewrote its inline `anchor-name` per
render, so a switch also produced roughly 2,500 style writes.

Every dependency these actions close over is a ref box, a React state
setter, or a method of the once-created session-UI controller, so they
are constant by construction rather than by discipline.
`createSessionWorkspaceActions` moves them out of the render body and
the hook instantiates it once; `refreshSessions` and `seedSessions` get
the same treatment. This is why they are not routed through
`useStableActions`, whose facade exists for factories whose closures do
capture changing deps.

Measured by alternating the two identities inside one running instance,
six switches each: row renders 459 to 85, DOM mutations 3,438 to 1,992,
renderer JS 521 ms to 380 ms, and renderer CPU under a repeated-switch
loop 34% to 24% with peaks falling from 77% to 55%.

Two imports in the session-list hook gain their `.js` extension so the
workspace module tree loads under Node, which the new identity contract
test needs.

Generated-by: Claude Code
`@maka/ui`'s `formatAbsoluteTimestamp` was a second copy of the `Intl`
options `@maka/core/relative-time` already owned, and it built a
formatter on every call — the session sidebar reads one per row for the
tooltip and one for the row's accessible name, so a single session
switch constructed roughly 1,300 of them. Core's own cache could not
have absorbed that either: `getRelativeFormat` and `getAbsoluteFormat`
shared one `cachedLocale` and cleared each other on a miss, so
alternating readings of the same timestamp rebuilt a formatter every
call.

Core now caches each formatter with its own locale and exports
`formatAbsoluteTimestamp`; the UI copy is re-exported rather than
reimplemented, so the tooltip and the accessible name cannot drift.

Profiling attributed about 33 ms per session switch to the
constructions. An A/B inside one running instance, swapping a memoising
`Intl.DateTimeFormat` in and out six times each, moved renderer JS by
less than the run-to-run spread — this is a duplicate-authority removal,
not a measurable win.

Generated-by: Claude Code
The rail's cost has had several independent causes — `setActiveId`
changing identity on every AppShell render, `Intl` formatters rebuilt
per row, catalog refreshes replacing unchanged row objects — and each
was invisible to the others. Asserting identities pins one mechanism in
one hook; the next plain function declaration upstream passes every
existing check, because the dependency arrays stay correct.

So the assertion is on the outcome: switching a session may write at
most three inline styles per rail row. Inline `style` is the dominant
term, since every Astryx button removes and re-adds its `anchor-name`
per render, and it needs no React internals to observe — a
`MutationObserver` over the rail is the whole probe.

Measured on the new twelve-row fixture: 4 writes when the rail behaves,
the leaving and the arriving row at two each, stable across runs;
336 with `setActiveId` restored to a per-render identity. The budget of
36 sits an order of magnitude clear of both.

This also covers the unattributed commit cascade in apache#4109: whatever
raises the number of commits a switch produces shows up here.

Generated-by: Claude Code
The budget was a total, scaled by row count, and one-sided. Each of those
let a real regression through.

A total of `3 * rows` is 1.5 whole-rail renders, so a change that renders
the rail exactly once more than it should stayed under it — and that is
the likeliest regression, because `renderSessionRow` depends on
`rowActions`, `sessionMeta` and three Sets, any of which becoming a fresh
object per render defeats `SessionNavRow`'s memo for every row at once.
The identity test could not see it either: it reads the workspace hook's
return value, not what AppShell assembles from it. Attributing each write
to its row removes the hole and the row-count coupling together — a
switch touches the leaving row and the arriving row, whatever the rail's
length — and it asserts the fix's own missing middle, that memo holding
means untouched rows do no DOM work.

Counting remounts closes the other side: React sets attributes before
insertion, so an attribute-only observer reads a whole rail unmounting
and remounting as CHEAPER than a re-render.

`styleWrites > 0` is the counter's liveness check. Every write counted
comes from an Astryx ref callback with no `useCallback` around it; if
that is ever memoised upstream, healthy and regressed readings both
collapse to zero and a one-sided budget passes forever.

The two fixed `waitForTimeout` calls were the only thing keeping a slow
machine out of the measurement window, with `retries: 0` behind them.
Polling until the counter is quiet for ~300ms states the actual
precondition, and runs faster: 2.2s against 3.6s.

The identity test now derives its keys from the hook's return value.
The hand-kept list covered 11 of the 23 functions it returns and would
have kept covering 11 as more were added.

Verified by reverting the fix in the built renderer bundle: the run fails
on rows-touched, and passes three times in a row with the fix in place.

Generated-by: Claude Code
Extracting the workspace actions gave this type an owner and an export.
Leaving the three local copies in place would have made the PR that
merged one duplicate authority create another.

Generated-by: Claude Code
Once the implementation moved to `@maka/core/relative-time`, the export
left behind in `chat-display-helpers` held nothing — it was a second name
for the same function, and `relative-time.tsx` reached the one module
through both names at once. Drift is prevented by there being a single
implementation, not by which file the callers name.

`formatAbsoluteTimestamp` is not in the package's public exports, so this
moves three imports and removes a concept without changing a contract.

Generated-by: Claude Code
@Astro-Han
Astro-Han force-pushed the fix/renderer-stabilize-session-workspace-actions branch from 205973b to 35c0ffd Compare August 29, 2026 03:08

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found no P0–P3 issues on exact head 35c0ffdfcfa92ebb54c41d480644d41efec94a2e.

setActiveId was a function declaration inside useAppShellSessionWorkspace, so it changed identity on every AppShell render. AppShell passes it as activateSession into the Session navigation controller, which rebuilds commands, then rowActions / onSelectSession, then renderSessionRow, which defeats SessionNavRow's memo. That is a reachable session-switch path.

The factory only closes over ref boxes, React state setters, and methods of the once-created session-UI controller, so one instance for the renderer's lifetime is the right cut. refreshSessions / seedSessions get the same treatment. The identity test reads every function the hook returns rather than a hand-kept list; the rail contract asserts which rows were written, so a single whole-rail re-render cannot hide. I did not treat issue #4109 as evidence.

The formatter change matches the same path: relative and absolute readings no longer share one locale slot, and the UI copy of formatAbsoluteTimestamp is gone. Call sites still pass locale explicitly.

I am not merging. Hosted test was still queued when I posted. This review does not claim CI is green.


Posted by an automated review agent operated by @WAWQAQ. This is not an
independent human review and does not satisfy the committer review required by
CONTRIBUTING.md. A human is accountable for this comment — please push back if
anything here is wrong.

简体中文

精确 head 35c0ffdfcfa92ebb54c41d480644d41efec94a2e 上我没有发现 P0–P3。

setActiveId 写在 useAppShellSessionWorkspace 函数体里,每次 AppShell 渲染都会换新身份,再传到导航控制器的 activateSession,一路打掉 SessionNavRowmemo。这是会话切换会走到的路径。

工厂只闭合 ref、React setter,以及一次性创建的 session-UI 控制器方法,所以渲染器生命周期内只建一次是对的。refreshSessions / seedSessions 同样处理。身份测试读 hook 返回的全部函数;栏合同断言被写到的行,整栏重渲染一次也藏不住。我没有把 issue #4109 当证据。

相对和绝对时间格式化不再共用一个 locale 槽;UI 里那份 formatAbsoluteTimestamp 已删。调用点仍显式传 locale。

我不合入。发这条时 hosted test 还在排队,这次审查不表示 CI 已绿。

本条评论由 @WAWQAQ 运行的自动化审查程序发出。它不构成 CONTRIBUTING.md
所要求的独立人类审查,也不能替代人类审查。有人类对本条评论负责,如有错误请直接指出。

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found no P0-P3 issues on exact head 35c0ffdfcfa92ebb54c41d480644d41efec94a2e.

The once-created workspace actions read changing state through refs and retain the previous selection, message, transient-projection, reload-intent, retry, stop, and catalog behavior. They do not freeze mutable session state in closures. The extraction is therefore a real lifetime boundary, not only a file move. The timestamp cleanup also removes the UI's duplicate absolute formatter and gives relative and absolute formatting independent locale caches.

I verified this with the complete Desktop main suite (1,650 tests), the complete UI suite (264 tests), the Core timestamp suite, the workspace identity test, and three consecutive real-Electron rail-contract runs. Two mutations independently restored the unstable action identity and the formatter-cache thrash; each made its targeted regression test fail. The current-main merge tree is clean and preserves every reviewed changed-file blob.

Hosted windows_recovery is green. Hosted test is still in progress, so this approval does not claim that CI is complete. I am not merging this pull request.


Posted by an automated review agent operated by @M4n5ter. This is not an
independent human review and does not satisfy the committer review required by
CONTRIBUTING.md. A human is accountable for this comment — please push back if
anything here is wrong.

简体中文

本条评论由 @M4n5ter 运行的自动化审查程序发出。它不构成 CONTRIBUTING.md
所要求的独立人类审查,也不能替代人类审查。有人类对本条评论负责,如有错误请直接指出。

@zhiiw zhiiw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at exact head 35c0ffdf (15 files, +680/-220).

The fix is sound by construction: everything the once-created action factory captures is a ref box, a React state setter, or a method of the once-created session-UI controller — I traced the one non-obvious capture (sessionUi.clearSessionUiState) to its controller to confirm. The moved bodies match the previous hook-body implementations; the only behavioral deltas are the two the description names, both unreachable from current call sites.

The rail contract spec budgets the outcome (rows touched ≤ 2, zero remounts, bounded style writes) rather than the mechanism, and its liveness assertion (styleWrites > 0) keeps the budget from passing vacuously if the upstream write source is ever memoised away.

Verified locally: clean rebuild at this head, session-workspace-action-identity and relative-time suites green. Checks at this head: test and windows_recovery both completed/success.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/L Under 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants